SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
7.9 KB · 196 lines tsx
Raw Blame History
1import Link from "next/link";2import { notFound } from "next/navigation";3import { ArrowLeft } from "lucide-react";4import { requireAdmin } from "@/lib/session";5import { getUserDetail } from "@/lib/queries/admin";6import { formatDate, formatNumber, truncate } from "@/lib/format";7import { PageHeader } from "@/components/ui/page-header";8import { Badge, StatusBadge } from "@/components/ui/badge";9import { Button } from "@/components/ui/button";10import { Stat, StatGrid } from "@/components/ui/stat";11import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";12import { Alert } from "@/components/ui/alert";13import { CopyButton } from "@/components/ui/copy-button";14import { DateCell, KV, Mono, Panel, PlanBadge } from "@/components/admin/primitives";15import { UserActions } from "@/components/admin/user-actions";1617export const dynamic = "force-dynamic";1819export default async function AdminUserDetail({ params }: { params: Promise<{ id: string }> }) {20  const admin = await requireAdmin();21  const { id } = await params;22  const d = await getUserDetail(id);23  if (!d) notFound();24  const { user } = d;2526  return (27    <>28      <div className="mb-4">29        <Button asChild variant="ghost" size="sm" className="-ml-2">30          <Link href="/admin/users">31            <ArrowLeft className="size-3.5" /> Users32          </Link>33        </Button>34      </div>35      <PageHeader36        eyebrow="User"37        title={38          <span className="flex flex-wrap items-center gap-2">39            {user.email}40            {user.role === "admin" ? <Badge variant="danger">admin</Badge> : null}41            {user.banned ? <Badge variant="danger" dot>banned</Badge> : null}42            {user.emailVerified ? <Badge variant="success" dot>verified</Badge> : <Badge variant="outline">unverified</Badge>}43          </span>44        }45        description={46          <span className="inline-flex items-center gap-1">47            <Mono>{user.id}</Mono>48            <CopyButton value={user.id} size="icon-sm" className="size-6" />49          </span>50        }51        actions={<UserActions userId={user.id} banned={user.banned} role={user.role} emailVerified={user.emailVerified} isSelf={user.id === admin.id} />}52      />5354      {user.banned ? (55        <Alert variant="danger" title="This account is banned" className="mb-4">56          Reason: {user.banReason || "not recorded"}57        </Alert>58      ) : null}5960      <StatGrid cols={4} className="mb-6">61        <Stat label="Organizations" value={formatNumber(d.memberships.length)} />62        <Stat label="Projects" value={formatNumber(d.projectsCount)} />63        <Stat label="API keys" value={formatNumber(d.keysCount)} hint="incl. revoked" />64        <Stat label="Requests (30d)" value={formatNumber(d.requests30d)} hint="across their organizations" />65      </StatGrid>6667      <div className="grid gap-6 lg:grid-cols-2">68        <Panel title="Profile">69          <KV70            items={[71              { label: "Name", value: user.name || "—" },72              { label: "Email", value: user.email },73              { label: "Role", value: user.role },74              { label: "Email verified", value: user.emailVerified ? "yes" : "no" },75              { label: "Onboarding completed", value: user.onboardingCompletedAt ? formatDate(user.onboardingCompletedAt) : "not yet" },76              { label: "Created", value: formatDate(user.createdAt) },77              { label: "Updated", value: formatDate(user.updatedAt) },78              { label: "Image", value: user.image ? truncate(user.image, 40) : "—", mono: true },79            ]}80          />81        </Panel>82        <Panel title="Organizations" flush>83          <Table>84            <TableHeader>85              <TableRow>86                <TableHead>Organization</TableHead>87                <TableHead>Plan</TableHead>88                <TableHead>Role</TableHead>89                <TableHead>Status</TableHead>90                <TableHead className="text-right">Joined</TableHead>91              </TableRow>92            </TableHeader>93            <TableBody>94              {d.memberships.length === 0 ? (95                <TableEmpty colSpan={5}>No memberships.</TableEmpty>96              ) : (97                d.memberships.map((m) => (98                  <TableRow key={m.organizationId}>99                    <TableCell>100                      <Link href={`/admin/organizations/${m.organizationId}`} className="font-medium hover:underline">101                        {m.name}102                      </Link>103                    </TableCell>104                    <TableCell>105                      <PlanBadge plan={m.plan} />106                    </TableCell>107                    <TableCell className="text-fg-muted">{m.role}</TableCell>108                    <TableCell>{m.suspended ? <StatusBadge status="suspended" /> : <StatusBadge status="active" />}</TableCell>109                    <TableCell className="text-right">110                      <DateCell value={m.joinedAt} />111                    </TableCell>112                  </TableRow>113                ))114              )}115            </TableBody>116          </Table>117        </Panel>118      </div>119120      <Panel title="Sessions" description="Most recent 10 login sessions." flush className="mt-6">121        <Table>122          <TableHeader>123            <TableRow>124              <TableHead>Created</TableHead>125              <TableHead>Expires</TableHead>126              <TableHead>IP</TableHead>127              <TableHead>User agent</TableHead>128            </TableRow>129          </TableHeader>130          <TableBody>131            {d.sessions.length === 0 ? (132              <TableEmpty colSpan={4}>Never logged in.</TableEmpty>133            ) : (134              d.sessions.map((s) => (135                <TableRow key={s.id}>136                  <TableCell>137                    <DateCell value={s.createdAt} relative={false} />138                  </TableCell>139                  <TableCell>140                    <DateCell value={s.expiresAt} />141                  </TableCell>142                  <TableCell>143                    <Mono>{s.ipAddress ?? "—"}</Mono>144                  </TableCell>145                  <TableCell className="max-w-[420px] truncate text-[12.5px] text-fg-muted" title={s.userAgent ?? undefined}>146                    {s.userAgent ?? "—"}147                  </TableCell>148                </TableRow>149              ))150            )}151          </TableBody>152        </Table>153      </Panel>154155      <Panel title="Audit trail" description="Last 50 audit entries where this user is the actor." flush className="mt-6">156        <Table>157          <TableHeader>158            <TableRow>159              <TableHead>When</TableHead>160              <TableHead>Action</TableHead>161              <TableHead>Target</TableHead>162              <TableHead>Metadata</TableHead>163              <TableHead>IP</TableHead>164            </TableRow>165          </TableHeader>166          <TableBody>167            {d.audit.length === 0 ? (168              <TableEmpty colSpan={5}>No audit entries.</TableEmpty>169            ) : (170              d.audit.map((a) => (171                <TableRow key={a.id}>172                  <TableCell>173                    <DateCell value={a.createdAt} relative={false} />174                  </TableCell>175                  <TableCell>176                    <Badge variant={a.action === "admin.action" ? "danger" : "default"}>{a.action}</Badge>177                  </TableCell>178                  <TableCell>179                    <Mono>{a.target ?? "—"}</Mono>180                  </TableCell>181                  <TableCell className="max-w-[360px] truncate font-mono text-[12px] text-fg-muted" title={a.metadata ? JSON.stringify(a.metadata) : undefined}>182                    {a.metadata ? JSON.stringify(a.metadata) : "—"}183                  </TableCell>184                  <TableCell>185                    <Mono>{a.ipAddress ?? "—"}</Mono>186                  </TableCell>187                </TableRow>188              ))189            )}190          </TableBody>191        </Table>192      </Panel>193    </>194  );195}196